File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/membership-gate.php
<?php
// includes/membership-gate.php
//
// Server-side recipe-card gating (gate_type: recipe_card, the only gate type in v1).
//
// A recipe whose synced overview carries a non-empty `gated` array (product ids, OR rule:
// owning any listed product grants access) renders its recipe card only when the verified
// first-party member session (includes/membership.php) contains a matching product. Denied
// readers get a server-rendered gate placeholder IN PLACE of the card - the card's markup is
// never sent to the browser, so nothing client-side can reveal it. The rest of the article is
// untouched.
//
// Card location is adapter-based (req 12): each adapter knows one recipe-card provider's real
// DOM boundaries. v1 ships the WPRM adapter built on the plugin's existing boundary helpers.
// Nothing is ever matched by the word "recipe" (req 11).
if (!defined('ABSPATH')) exit;
/* ------------------------------------------------------------------------------ adapters */
/*
* Adapter contract: ['id' => string, 'find_all' => callable(string $content): array of
* ['start' => int, 'end' => int]] - byte offsets of complete card elements, in document
* order, non-overlapping. Extend via the filter to support future providers.
*/
function allspice_recipe_card_adapters(): array {
$adapters = [
[
'id' => 'wprm',
'find_all' => 'allspice_gate_wprm_find_all',
],
];
return apply_filters('allspice_recipe_card_adapters', $adapters);
}
/*
* All WPRM cards in document order (req 13: deterministic for multiple cards). The existing
* helper finds the FIRST card; iterate over suffixes, mapping offsets back to the full
* string. Bounds cannot overlap because each scan resumes after the previous card's end.
*/
function allspice_gate_wprm_find_all(string $content): array {
$found = [];
$offset = 0;
for ($i = 0; $i < 20; $i++) { // hard cap: no post has 20 recipe cards; guards a scan bug
$slice = substr($content, $offset);
if ($slice === '' || $slice === false) break;
$bounds = allspice_find_wprm_recipe_bounds($slice);
if (!$bounds) break;
$found[] = [
'start' => $offset + (int)$bounds['start'],
'end' => $offset + (int)$bounds['end'],
];
$offset = $offset + (int)$bounds['end'];
}
return $found;
}
/* --------------------------------------------------------------------------- gate decision */
/* Gated product ids for the current post's recipe, from the SYNCED overview row. */
function allspice_gate_current_recipe(): array {
static $cached = null;
if (is_array($cached)) return $cached;
$cached = ['recipe_id' => '', 'gated' => []];
if (!function_exists('allspice_is_recipe_page') || !allspice_is_recipe_page()) return $cached;
if (!function_exists('allspice_current_url_key') || !function_exists('allspice_recipes_get_overview_by_url_key')) return $cached;
$uk = allspice_current_url_key();
if (!is_string($uk) || $uk === '') return $cached;
$row = allspice_recipes_get_overview_by_url_key($uk);
if (is_object($row)) $row = (array)$row;
if (!is_array($row)) return $cached;
/* PRODUCTION SYNC SHAPE: gated is a list of OBJECTS,
[{ product_id: "product_123", gate_type: "recipe_card" }].
Object entries only; select by gate_type === 'recipe_card' (never by array order or
gates[0]); extract a clean non-empty product_id; dedupe. A string cast here is exactly
the bug that produced "Array" in the placeholder. */
$gated = [];
if (isset($row['gated']) && is_array($row['gated'])) {
foreach ($row['gated'] as $g) {
if (!is_array($g)) continue;
if (trim((string)($g['gate_type'] ?? '')) !== 'recipe_card') continue;
$pid = trim((string)($g['product_id'] ?? $g['productId'] ?? $g['productID'] ?? ''));
if ($pid !== '') $gated[] = $pid;
}
}
$cached = [
'recipe_id' => trim((string)($row['recipe_id'] ?? '')),
'gated' => array_values(array_unique($gated)),
];
return $cached;
}
function allspice_gate_memberships_active(): bool {
/* Schema v2: configured = nonempty site_membership_id; legacy: enabled+ready. NOTE:
new_signups_enabled has no effect here - enforcement and member access never depend
on whether NEW purchases are open. */
if (function_exists('allspice_memberships_configured')) return allspice_memberships_configured();
if (!function_exists('allspice_memberships_config')) return false;
$m = allspice_memberships_config();
return ($m['enabled'] ?? null) === true && ($m['ready'] ?? null) === true;
}
/*
* Fallback policy when a gated recipe's card CANNOT be safely located. Only WPRM is a
* supported provider today, so during initial rollout the DEFAULT IS 'open': the recipe
* renders, and a loud telemetry event records that gating did not activate - recipe-card
* gating must never silently become an entire-post paywall on an unsupported provider.
* 'closed' (replace the whole content with the gate) stays available strictly as an explicit
* developer override via the filter, for publishers who prefer enforcement over availability.
*/
function allspice_gate_fallback_policy(): string {
$policy = apply_filters('allspice_gate_fallback_policy', 'open');
return $policy === 'closed' ? 'closed' : 'open';
}
/* ------------------------------------------------------------------------------ rendering */
function allspice_gate_placeholder_html(array $recipe): string {
/* recipe_card copy from the normalized model - strictly this gate type's object (v2
nested / legacy gates[] both handled by the ONE normalizer), never gates[0], never
shared with the article gates. */
$c = allspice_membership_gate_copy('recipe_card');
$benefits = function_exists('allspice_membership_benefits_html')
? allspice_membership_benefits_html(allspice_membership_benefits_limit())
: '';
/* data-* payload: PUBLIC identifiers only (product ids are public config). The buttons
are plain anchors carrying both the data-allspice-action hook AND the hash href, so
they work through the page bundle's global hooks and - without JS - via hash routes. */
$products_attr = esc_attr(implode(',', array_map('strval', $recipe['gated'])));
return allspice_gate_embedded_css()
. '<div class="allspice-recipe-gate" id="allspice-recipe-gate"'
. ' data-allspice-gate-type="recipe_card"'
. ' data-allspice-recipe-id="' . esc_attr($recipe['recipe_id']) . '"'
. ' data-allspice-gate-products="' . $products_attr . '"'
. ' data-allspice-gate-config-version="' . esc_attr(function_exists('allspice_memberships_config_version') ? allspice_memberships_config_version() : '') . '">'
. '<div class="allspice-recipe-gate__inner">'
. '<div class="allspice-recipe-gate__brand" data-allspice-gate-brand></div>'
. '<h3 class="allspice-recipe-gate__title">' . esc_html($c['title']) . '</h3>'
. '<p class="allspice-recipe-gate__copy">' . esc_html($c['copy']) . '</p>'
. $benefits
. allspice_membership_gate_actions_html($c['join_label'], $c['login_label'])
. '<div class="allspice-recipe-gate__status" data-allspice-gate-status aria-live="polite"></div>'
. '</div></div>';
}
/*
* Lightweight gate stylesheet (req 14): inline on a registered handle - always available
* when memberships are live, no dependency on the widget CSS or on the reader ever opening
* Allspice, and no extra HTTP request.
*/
add_action('wp_enqueue_scripts', 'allspice_gate_enqueue_styles', 20);
function allspice_gate_enqueue_styles(): void {
if (!allspice_gate_memberships_active()) return;
wp_register_style('allspice-gate', false, [], defined('ALLSPICE_PLUGIN_VERSION') ? ALLSPICE_PLUGIN_VERSION : null);
wp_enqueue_style('allspice-gate');
wp_add_inline_style('allspice-gate', allspice_gate_css());
}
/*
* The ONE gate stylesheet, shared by the enqueue above and the in-body embed below.
* Includes the legacy membership-landing rules (formerly a separate inline handle in
* membership-page.php) so every membership surface is covered by one payload.
*/
function allspice_gate_css(): string {
return /* Base gate rules consume the --allspice-gate-* custom properties (emitted below
from SANITIZED memberships.gate_style values only) with the original hardcoded
look as the var() fallback - no config string ever lands in CSS unsanitized. */
'.allspice-recipe-gate{border:var(--allspice-gate-border-width,1px) solid var(--allspice-gate-border,#e5e5ea);'
. 'border-radius:var(--allspice-gate-radius,16px);padding:28px 22px;margin:24px 0;text-align:center;'
. 'background:var(--allspice-gate-background,#fafafa);color:var(--allspice-gate-text,inherit);'
. 'font-family:var(--allspice-gate-font-family,inherit)}'
. '.allspice-recipe-gate__inner{max-width:460px;margin:0 auto}'
/* display:block + margin auto, NOT inline centering: themes force `.entry-content img`
to display:block, and a block image in a text-align:center card sits flush LEFT
(live finding, baking4happiness 2026-08-26). Owning the display owns the centering. */
. '.allspice-recipe-gate__brand img{max-height:44px;max-width:200px;object-fit:contain;display:block;margin:0 auto}'
/* SPACING IS DEFENDED AT (0,3,0), not decorated. This card renders inside the
publisher's theme, and a theme that scopes its typography (`.entry-content h3`
and friends, a very common shape) sits at (0,2,0) and beat the gate's own
(0,1,0) class rules - which is why the card rendered with ~42px above the title
where 10px was specified, and read as loose and unfinished. Every rule that owns
vertical rhythm is therefore scoped through __inner. */
. '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-recipe-gate__brand{margin:0 0 16px}'
. '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-recipe-gate__title{margin:0 0 6px;font-size:1.25em;line-height:1.3;color:var(--allspice-gate-heading,inherit)}'
. '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-recipe-gate__copy{margin:0 0 20px;opacity:.8;line-height:1.5}'
. '.allspice-recipe-gate__actions{display:flex;flex-direction:column;gap:12px;align-items:center}'
. '.allspice-recipe-gate__join{display:inline-block;padding:11px 26px;border-radius:999px;'
. 'background:var(--allspice-gate-button-background,#1f2430);color:var(--allspice-gate-button-text,#fff) !important;'
. 'text-decoration:none !important;font-weight:600;cursor:pointer}'
. '.allspice-recipe-gate__login{font-size:.95em;text-decoration:underline;cursor:pointer}'
. '.allspice-recipe-gate__closed{margin:0;font-size:.95em;opacity:.8}'
. '.allspice-recipe-gate__status{min-height:1.2em;margin-top:10px;font-size:.9em;opacity:.75}'
/* ...but not when there is nothing to say. min-height reserved a line plus its
margin under the CTA on every render, which is the dead band at the foot of the
card. It still reserves space once populated, so a message appearing does not
shift the layout. */
. '.allspice-recipe-gate__status:empty{display:none}'
. '.allspice-recipe-gate.is-loading .allspice-recipe-gate__actions{opacity:.5;pointer-events:none}'
/* Benefits checklist (gates, landing, CTA block): responsive single column, inline
SVG check inherits the gate text color. */
. '.allspice-membership-benefits{list-style:none;padding:0;margin:0 auto 16px;max-width:420px;text-align:left}'
. '.allspice-membership-benefits li{display:flex;align-items:flex-start;gap:8px;margin:8px 0}'
/* Same defence for the list, which a theme also styles by element. */
/* List layout re-asserted at the defended (0,3,0) scope: theme `.entry-content ul/li`
rules at (0,2,0) re-add bullets, padding and list-item display over the base
(0,1,0) rules above. */
. '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-membership-benefits{margin:0 auto 22px;list-style:none;padding:0}'
. '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-membership-benefits li{margin:0 0 12px;display:flex;align-items:flex-start;gap:10px;padding:0;list-style:none}'
. '.allspice-recipe-gate .allspice-recipe-gate__inner .allspice-membership-benefits li:last-child{margin-bottom:0}'
/* Centered on the FIRST TEXT LINE whatever the theme's type metrics: the fixed
margin-top:2px assumed small type, and against an 18px/1.6 theme the check rode
visibly high (live finding, baking4happiness 2026-08-26). The lh-based margin
computes the exact first-line offset; the em value approximates it for engines
without lh units. */
. '.allspice-membership-benefits__check{flex:0 0 auto;display:inline-flex;margin-top:.3em;margin-top:calc((1lh - 16px)/2);color:var(--allspice-gate-button-background,#1f2430)}'
. '.allspice-membership-benefits__check svg{display:block;width:16px;height:16px}'
. '.allspice-membership-benefits__text{min-width:0}'
. '.allspice-membership-benefits__text strong{display:block;font-weight:600}'
. '.allspice-membership-benefits__desc{display:block;font-size:.9em;opacity:.75}'
/* Soft-overlay gate (default content-gate mode): clamp the lead-in, fade it into the
gate background, and let the page bundle lock scroll once the gate is reached.
content_immediate shows only a teaser sliver; content_preview a tall lead-in. */
. '.allspice-soft-gate{position:relative}'
. '.allspice-soft-gate__content{overflow:hidden;pointer-events:none;user-select:none;-webkit-user-select:none}'
. '.allspice-soft-gate--immediate .allspice-soft-gate__content{max-height:24vh}'
. '.allspice-soft-gate--preview .allspice-soft-gate__content{max-height:72vh}'
. '.allspice-soft-gate__fade{position:relative;height:150px;margin-top:-150px;pointer-events:none;'
. 'background:linear-gradient(to bottom,rgba(255,255,255,0) 0%,var(--allspice-gate-background,#fafafa) 88%)}'
. '.allspice-soft-gate .allspice-recipe-gate{margin-top:0}'
/* Armed by the page bundle on a gated page. The stop itself is a JS clamp on DOWNWARD
travel only (so the reader can always scroll back up); this rule just stops the
rubber-band/scroll-chaining at the stop so the clamp settles instead of bouncing.
Deliberately NOT `overflow:hidden` - that froze both directions, and a stale cached
bundle applying the old `asx-soft-gate-locked` class now matches nothing (fail-open:
the content above stays CSS-clamped either way) rather than freezing the page. */
. 'html.asx-soft-gate-armed{overscroll-behavior-y:none}'
. allspice_gate_style_vars_css()
/* Legacy [allspice_membership] landing wrapper (membership-page.php). */
. '.allspice-membership-page{max-width:560px;margin:0 auto;padding:8px 4px;text-align:center}'
. '.allspice-membership-page__brand img{max-height:52px;max-width:220px;object-fit:contain}'
. '.allspice-membership-page__title{margin:10px 0 6px}'
. '.allspice-membership-page__copy{opacity:.85;margin:0 0 14px}'
. '.allspice-membership-page__benefits{list-style:none;padding:0;margin:0 auto 18px;display:inline-block;text-align:left}'
. '.allspice-membership-page__benefits li{margin:6px 0;padding-left:22px;position:relative}'
. '.allspice-membership-page__benefits li:before{content:"";position:absolute;left:0;top:.25em;width:13px;height:13px;background:currentColor;-webkit-mask:url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.5 10.5l3.4 3.4 7.6-8" fill="none" stroke="black" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>\') no-repeat center/contain;mask:url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.5 10.5l3.4 3.4 7.6-8" fill="none" stroke="black" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>\') no-repeat center/contain}'
. '.allspice-membership-page__actions{display:flex;flex-direction:column;gap:10px;align-items:center}'
. '.allspice-membership-page__manage{margin-top:22px;font-size:.95em}'
. '.allspice-membership-page__manage a{display:inline-block;padding:9px 20px;'
. 'border:1px solid currentColor;border-radius:999px;text-decoration:none;'
. 'line-height:1.2;opacity:.7;transition:opacity .15s ease}'
. '.allspice-membership-page__manage a:hover,'
. '.allspice-membership-page__manage a:focus-visible{opacity:1}'
. '@media(min-width:600px){.allspice-membership-page{padding:16px 0}}'
/* Signed-in-but-not-a-member chip: replaces "Already a member? Log in" once the
widget reports a real login (membership-runtime.js swaps them). */
. '.allspice-membership-signedin{display:inline-flex;align-items:center;gap:6px;'
. 'opacity:.75;font-size:.95em;line-height:1.2}'
. '.allspice-membership-signedin svg{display:block}';
}
/*
* OPTIMIZER ARMOR (seen live on baking4happiness 2026-08-25): used-CSS optimizers empty
* head inline-style tags they judge unused, and ours was gutted - naked gate card, glued
* CTA buttons, misaligned benefit checks, and (worst) the CSS teaser clamp gone, so the
* whole server-included lead-in showed unclamped. Protected content itself is removed
* server-side and was never exposed, but the paywall must LOOK like a paywall regardless
* of what an optimizer does to the head. So the first gate/CTA markup on a page carries
* the same stylesheet as an IN-BODY <style>, which those optimizers leave alone. On
* unoptimized sites this duplicates a few KB of rules - identical selectors, identical
* values, zero visual effect - which is the price of rendering correctly everywhere.
*/
function allspice_gate_embedded_css(): string {
static $done = false;
if ($done) return '';
$done = true;
return '<style id="allspice-gate-embedded-css">' . allspice_gate_css()
. (function_exists('allspice_membership_cta_css') ? allspice_membership_cta_css() : '')
. '</style>';
}
/*
* The --allspice-gate-* custom-property block from SANITIZED gate_style values. Only values
* that passed the strict sanitizers are emitted; everything else falls back to the defaults
* baked into the base rules above. Never applied to protected article content - the
* selectors cover the GATE containers only (allspice-paywalled-content is untouched).
*/
function allspice_gate_style_vars_css(): string {
if (!function_exists('allspice_memberships_normalized')) return '';
$s = allspice_memberships_normalized()['gate_style'];
$vars = [];
if ($s['background_color'] !== null) $vars[] = '--allspice-gate-background:' . $s['background_color'];
if ($s['text_color'] !== null) $vars[] = '--allspice-gate-text:' . $s['text_color'];
if ($s['heading_color'] !== null) $vars[] = '--allspice-gate-heading:' . $s['heading_color'];
if ($s['border_color'] !== null) $vars[] = '--allspice-gate-border:' . $s['border_color'];
if ($s['border_width'] !== null) $vars[] = '--allspice-gate-border-width:' . $s['border_width'] . 'px';
if ($s['corner_radius'] !== null) $vars[] = '--allspice-gate-radius:' . $s['corner_radius'] . 'px';
if ($s['button_background_color'] !== null) $vars[] = '--allspice-gate-button-background:' . $s['button_background_color'];
if ($s['button_text_color'] !== null) $vars[] = '--allspice-gate-button-text:' . $s['button_text_color'];
if ($s['font_family'] !== null) $vars[] = '--allspice-gate-font-family:' . $s['font_family'];
if ($vars === []) return '';
return '.allspice-recipe-gate,.allspice-content-gate{' . implode(';', $vars) . '}';
}
/* -------------------------------------------------------------------------- enforcement */
/*
* Priority 99: after shortcodes (WPRM renders at the default 10-ish), so the card markup the
* adapters scan is the FINAL markup.
*/
add_filter('the_content', 'allspice_gate_filter_content', 99);
function allspice_gate_filter_content($content) {
if (!is_string($content) || $content === '') return $content;
if (is_feed() || is_admin()) return $content;
/* Singular content only - never widgets, excerpts, feeds. Identity guard instead of
loop-state (in_the_loop/is_main_query): custom CPT templates render the_content from
secondary queries where loop state lies but the post id still tells the truth. Same
theme-compat finding as allspice_content_enforce_filter. */
if (!is_singular()) return $content;
$post_id = (int)get_the_ID();
$queried = (int)get_queried_object_id();
if ($post_id <= 0 || $queried <= 0 || $post_id !== $queried) return $content;
/* Fail OPEN when memberships are disabled or not ready (req 8) - gating simply off. */
if (!allspice_gate_memberships_active()) return $content;
/* A whole-content gate (content-enforce.php) already replaced/reduced this content for
an unauthorized reader - never render the recipe-card gate on top of it. Authorized
members fall through and get normal recipe-card behavior. */
if (function_exists('allspice_content_gate_overrides_recipe') && allspice_content_gate_overrides_recipe()) {
return $content;
}
$recipe = allspice_gate_current_recipe();
if ($recipe['gated'] === []) return $content;
/* OR rule: any matching product in the VERIFIED member session grants access (req 3). */
if (function_exists('allspice_member_has_any_product') && allspice_member_has_any_product($recipe['gated'])) {
return $content; // untouched (req 4)
}
/* Locate every card via adapters, replace each with the gate (first card gets the full
gate; later duplicates collapse to nothing so one article never shows two gates). */
$all = [];
foreach (allspice_recipe_card_adapters() as $adapter) {
if (!is_array($adapter) || !is_callable($adapter['find_all'] ?? null)) continue;
$found = call_user_func($adapter['find_all'], $content);
if (is_array($found)) {
foreach ($found as $b) {
if (is_array($b) && isset($b['start'], $b['end']) && $b['end'] > $b['start']) {
$all[] = ['start' => (int)$b['start'], 'end' => (int)$b['end']];
}
}
}
}
if ($all === []) {
/* Gated recipe, card not safely locatable (req 9): log + follow the policy filter. */
if (function_exists('allspice_console_log')) {
/* Telemetry: unsupported provider / boundary failure - gating did not activate. */
allspice_console_log('[Allspice] GATE ERROR: gated recipe but no recipe card located (rule not activated)', [
'recipe_id' => $recipe['recipe_id'],
'policy' => allspice_gate_fallback_policy(),
]);
}
if (allspice_gate_fallback_policy() === 'open') return $content;
return allspice_gate_placeholder_html($recipe);
}
/* Deterministic multi-card handling: sort by position, drop overlaps, replace from the
END backwards so earlier offsets stay valid. */
usort($all, static function ($a, $b) { return $a['start'] <=> $b['start']; });
$kept = [];
$cursor = -1;
foreach ($all as $b) {
if ($b['start'] < $cursor) continue; // overlap from a second adapter: first wins
$kept[] = $b;
$cursor = $b['end'];
}
$gate_html = allspice_gate_placeholder_html($recipe);
for ($i = count($kept) - 1; $i >= 0; $i--) {
$replacement = ($i === 0) ? $gate_html : '';
$content = substr($content, 0, $kept[$i]['start'])
. $replacement
. substr($content, $kept[$i]['end']);
}
return $content;
}